- Published on
RAG检索增强生成(二)
- Authors

- Name
- 游戏人生
大规模数据预处理
受限于常见 llm 的上下文大小,例如 gpt3.5 是 16k、gpt4 是 128k,不能把完整的数据整个塞到对话的上下文中,而且,即使数据源接近于 llm 的上下文窗口大小,llm 在读取数据时也很容易忽略其中的细节。所以需要对加载进来的数据切分,切分成较小的语块,然后根据对话的内容,将关联性比较强的数据塞到 llm 的上下文中,来强化 llm 输出的质量。
langchain 目前提供的切分工具:
| 名称 | 说明 |
|---|---|
| Recursive | 根据给定的切分字符(例如 \n\n、\n等),递归的切分 |
| HTML | 根据 html 特定字符进行切分 |
| Markdown | 根据 md 的特定字符进行切分 |
| Code | 根据不同编程语言的特定字符进行切分 |
| Token | 根据文本块的 token 数据进行切分 |
| Character | 根据用户给定的字符进行切割 |
RecursiveCharacterTextSplitter
RecursiveCharacterTextSplitter,最常用的切分工具,根据内置的一些字符对原始文本进行递归的切分,来保持相关的文本片段相邻,保持切分结果内部的语意相关性。默认的分隔符列表是 ["\n\n", "\n", " ", ""]。
影响切分质量的两个参数:
- chunkSize: 默认是 1000,切分后的文本块的大小,如果文本块太大,llm 的上下文窗口会不够,如果文本块太小,llm 的上下文窗口会太小,导致 llm 输出的结果不够准确。
- chunkOverlap: 默认是 200,切分后的文本块之间的重叠长度,如果文本块重叠的长度太长,llm 的上下文窗口会太小,导致llm 输出的结果不够准确。
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { TextLoader } from "langchain/document_loaders/fs/text";
const loader = new TextLoader("./data/2.txt");
const docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 64,
chunkOverlap: 0,
});
const splitDocs = await splitter.splitDocuments(docs);
console.log(splitDocs);
输出内容如下:
[
Document {
pageContent: "鲁镇的酒店的格局,是和别处不同的:都是当街一个曲尺形的大柜台,柜里面预备着热水,可以随时温酒。做工的人,傍午傍晚散了工,每每花四",
metadata: { source: "./data/2.txt", loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "文铜钱,买一碗酒,——这是二十多年前的事,现在每碗要涨到十文,——靠柜外站着,热热的喝了休息;倘肯多花一文,便可以买一碟盐煮笋,",
metadata: { source: "./data/2.txt", loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "或者茴香豆,做下酒物了,如果出到十几文,那就能买一样荤菜,但这些顾客,多是短衣帮,大抵没有这样阔绰。只有穿长衫的,才踱进店面隔壁",
metadata: { source: "./data/2.txt", loc: { lines: { from: 1, to: 1 } } }
},
...
]
可以在 ChunkViz 查看文本切分效果。
Code
langchain 所支持的语言是一直在变动的,可以通过这个函数查询目前支持的语言:
import { SupportedTextSplitterLanguages } from "langchain/text_splitter";
console.log(SupportedTextSplitterLanguages);
切分 js 代码案例:
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const js = `
function myFunction(name, job){
console.log("Welcome " + name + ", the " + job);
}
myFunction('小明','司机');
function forFunction(){
for (let i = 0; i < 5; i++){
console.log("当前数字是" + i)
}
}
forFunction();
`;
const splitter = RecursiveCharacterTextSplitter.fromLanguage("js", {
chunkSize: 64,
chunkOverlap: 0,
});
const jsOutput = await splitter.createDocuments([js]);
console.log(jsOutput);
输出内容如下:
[
Document {
pageContent: "function myFunction(name, job){",
metadata: { loc: { lines: { from: 2, to: 2 } } }
},
Document {
pageContent: 'console.log("Welcome " + name + ", the " + job);\n }',
metadata: { loc: { lines: { from: 3, to: 4 } } }
},
Document {
pageContent: "myFunction('小明','司机');",
metadata: { loc: { lines: { from: 6, to: 6 } } }
},
Document {
pageContent: "function forFunction(){\n \tfor (let i = 0; i < 5; i++){",
metadata: { loc: { lines: { from: 8, to: 9 } } }
},
Document {
pageContent: 'console.log("当前数字是" + i)\n \t}\n }',
metadata: { loc: { lines: { from: 10, to: 12 } } }
},
Document {
pageContent: "forFunction();",
metadata: { loc: { lines: { from: 14, to: 14 } } }
}
]
对 js 的分割本质就是将 js 中常见的切分代码的特定字符传给 RecursiveCharacterTextSplitter,然后还是根据 Recursive 的逻辑进行切分。
Token
根据 token 的数量进行切分,仅适合对 token 比较敏感的场景,或者与其他切分函数组合使用。
import { TokenTextSplitter } from "langchain/text_splitter";
const text = "From then on, I stood at the counter all day, focusing on my duties. Although I didn't fail to do my job, I always felt a little monotonous and bored. The shopkeeper had a fierce face, and the customers were not in a good mood, which made people feel depressed. Only when Kong Yiji came to the store could I laugh a few times, so I still remember it.";
const splitter = new TokenTextSplitter({
chunkSize: 20,
chunkOverlap: 0,
});
const docs = await splitter.createDocuments([text]);
console.log(docs);
输出内容如下:
[
Document {
pageContent: "From then on, I stood at the counter all day, focusing on my duties. Although I didn",
metadata: { loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "'t fail to do my job, I always felt a little monotonous and bored. The shop",
metadata: { loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: "keeper had a fierce face, and the customers were not in a good mood, which made people feel",
metadata: { loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: " depressed. Only when Kong Yiji came to the store could I laugh a few times, so I",
metadata: { loc: { lines: { from: 1, to: 1 } } }
},
Document {
pageContent: " still remember it.",
metadata: { loc: { lines: { from: 1, to: 1 } } }
}
]
构建向量数据库
Embedding
Embedding 是将文本转换为向量表示的过程,向量表示可以进行相似性搜索。
langchain 提供了多种 Embedding 模型,包括 OpenAI Embedding、AlibabaTongyiEmbeddings、BaiduQianfanEmbeddings 等。
首先将小说切分成块:
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const loader = new TextLoader("./data/2.txt");
const docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 100,
chunkOverlap: 20,
});
const splitDocs = await splitter.splitDocuments(docs);
console.log(splitDocs[0].pageContent);
这样切分出来的块较小,也会节约 embedding 时的花费。参考自langchain.js
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
// import { BaiduQianfanEmbeddings } from "@langchain/community/embeddings/baidu_qianfan";
const model = new AlibabaTongyiEmbeddings({
modelName: "text-embedding-v2", // text-embedding-v1, text-embedding-async-v1, text-embedding-async-v2
});
// const modal = new BaiduQianfanEmbeddings({
// modelName: "embedding-v1", // "embedding-v1" | "bge_large_zh" | "bge-large-en" | "tao-8k";
// })
const res = await model.embedQuery(splitDocs[0].pageContent);
console.log({ res });
输出内容如下:
[
0.0059506547612028894, 0.006405104322379424, 0.04041307981709012,
-0.01886294990158835, -0.039227559222716556, 0.011387583931510565,
0.014779490076523833, 0.0457084051386254, -0.0027415163744888794,
0.010537960838876172, -0.002453369007800859, 0.017795981366652138,
-0.00467951323501345, -0.008693817692072843, -0.04162494531356088,
0.011611515599336684, -0.00843036867110094, -0.032009056048086376,
0.03506506469136047, -0.013119761244400835, 0.021220818639286893,
0.033826854292792516, 0.025844348957343815, 0.005384239366113295,
0.05226828576082582, 0.010202063337136994, -0.006125189737596776,
-0.017875016072943707, 0.033326301152945895, 0.030718155845324044,
0.011888137071357182, 0.014384316545065976, 0.00448851269480882,
0.023670894534325602,
... 1436 more items
]
创建 MemoryVectorStore
MemoryVectorStore 是一个内存中的向量数据库,将文档和向量存储在内存中,方便后续查询。 embedding 向量是需要有一定花费的,所以仅在学习和测试时使用 MemoryVectorStore,而在真实项目中,搭建其他向量数据库,或者使用云数据库。
创建 MemoryVectorStore 的实例,传入需要 embeddings 的模型,调用添加文档的函数 addDocuments,langchain 的 MemoryVectorStore 会自动完成对每个文档请求 embeddings 的模型,然后存入数据库。
import { MemoryVectorStore } from "langchain/vectorstores/memory";
const vectorstore = new MemoryVectorStore(model);
await vectorstore.addDocuments(splitDocs);
创建一个 retriever,这也是可以直接从 vector store 的实例中自动生成的,传入参数 2,代表对应每个输入,需要返回相似度最高的两个文本内容。
const retriever = vectorstore.asRetriever(2);
// 提取文档内容
const res = await retriever.invoke("茴香豆是做什么用的");
console.log(res);
为了提高回答质量,返回更多的数据源是有价值的
构建本地 vector store
因为数据生成 embedding 需要一定的花费,所以把 embedding 的结果持久化,这样就可以在应用中持续复用。 推荐使用 facebook 开源的 faiss 向量数据库,既支持 js,也支持 python。
示例如下: 首先创建 node 项目,安装依赖
yarn add dotenv faiss-node langchain
将 txt 文件 embeddings,存入 db 文件夹内的文件中
import { TextLoader } from "langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import "dotenv/config";
import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
const run = async () => {
const loader = new TextLoader("./data/2.txt");
const docs = await loader.load();
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 100,
chunkOverlap: 20,
});
const splitDocs = await splitter.splitDocuments(docs);
const embeddings = new AlibabaTongyiEmbeddings({});
const vectorStore = await FaissStore.fromDocuments(splitDocs, embeddings);
const directory = "./db/2";
await vectorStore.save(directory);
};
run();
重新新建一个文件来加载存储好的 vector store:
import "dotenv/config";
import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
const directory = "./db/2";
const embeddings = new AlibabaTongyiEmbeddings({});
const vectorstore = await FaissStore.load(directory, embeddings);
const retriever = vectorstore.asRetriever(2); // 根据相似度返回相关内容
const res = await retriever.invoke("孔乙己是干什么的");
console.log(res);
输出内容如下:
[
{
pageContent: '孔乙己喝过半碗酒,涨红的脸色渐渐复了原,旁人便又问道,“孔乙己,你当真认识字么?”孔乙己看着问他的人,显出不屑置辩的神气。他们便接着说道,“你怎的连半个秀才也捞不到呢?”孔乙己立刻显出颓唐不安模样,',
metadata: { source: './data/2.txt', loc: [Object] }
},
{
pageContent: '洗。他对人说话,总是满口之乎者也,叫人半懂不懂的。因为他姓孔,别人便从描红纸上的“上大人孔乙己”这半懂不懂的话里,替他取下一个绰号,叫作孔乙己。孔乙己一到店,所有喝酒的人便都看着他笑,有的叫道,“孔乙',
metadata: { source: './data/2.txt', loc: [Object] }
}
]
retriever 常见优化方式
在 llm 中,如果用户提问的关键词缺少,或者跟原文中的关键词不一致,就容易导致 retriever 返回的文档质量不高,影响 llm 的最终输出效果。 因此可以对 retriever 进行优化,来提高返回内容与用户提问的相关性及内容质量。
MultiQueryRetriever
MultiQueryRetriever 是一个 retriever,它将输入的查询拆分成多个查询,然后分别调用基础的 retriever,然后合并结果。 MultiQueryRetriever 的构造函数需要传入基础的 retriever,以及拆分查询的函数。 MultiQueryRetriever 的 invoke 函数接收一个查询,然后调用基础的 retriever,合并结果。
import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
import { MultiQueryRetriever } from "langchain/retrievers/multi_query";
import "faiss-node";
import "dotenv/config";
async function run() {
const directory = "./db/2";
const embeddings = new AlibabaTongyiEmbeddings({});
const vectorstore = await FaissStore.load(directory, embeddings);
const model = new ChatAlibabaTongyi({
model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
temperature: 1,
});
const retriever = MultiQueryRetriever.fromLLM({
llm: model,
retriever: vectorstore.asRetriever(3), // 每次会检索三条数据,对每个 query
queryCount: 3, // 默认值是 3,表示对每条输入,llm 都会改写生成三条不同的写法和措词,表示同样意义的 query
verbose: true, // 设置为 true 会打印出 chain 内部的详细执行过程方便 debug
});
const res = await retriever.invoke("茴香豆是做什么用的");
console.log(res);
}
run();
Document Compressor
Retriever 的另一个问题,如果设置 k(每次检索返回的文档数)较小,因为自然语言的特殊性,可能相似度排名较高的并不是答案,就像搜索引擎依靠的也是相似性的度量,但排名最高的并不一定是最高质量的答案。而如果设置的 k 过大,就会导致大量的文档内容,可能会撑爆 llm 上下文窗口。
ContextualCompressionRetriever 是一个 retriever,它将输入的文档进行压缩,然后调用基础的 retriever。
import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
import "dotenv/config";
import { LLMChainExtractor } from "langchain/retrievers/document_compressors/chain_extract";
import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression";
// process.env.LANGCHAIN_VERBOSE = "true";
async function run() {
const directory = "./db/2";
const embeddings = new AlibabaTongyiEmbeddings();
const vectorstore = await FaissStore.load(directory, embeddings);
const model = new ChatAlibabaTongyi({
model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
temperature: 1,
});
const compressor = LLMChainExtractor.fromLLM(model);
const retriever = new ContextualCompressionRetriever({
baseCompressor: compressor,
baseRetriever: vectorstore.asRetriever(2),
});
const res = await retriever.invoke("茴香豆是做什么用的");
console.log(res);
}
run();
核心参数
- baseCompressor: 压缩上下文时会调用 chain,接收任何符合 Runnable interface 的对象
- baseRetriever: 在检索数据时用到的 retriever
ScoreThresholdRetriever
ScoreThresholdRetriever 是一个 retriever,它将输入的查询和文档进行匹配,然后根据匹配得分进行过滤。
import { FaissStore } from "@langchain/community/vectorstores/faiss";
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
import "dotenv/config";
import { ScoreThresholdRetriever } from "langchain/retrievers/score_threshold";
// process.env.LANGCHAIN_VERBOSE = "true";
async function run() {
const directory = "./db/kongyiji";
const embeddings = new AlibabaTongyiEmbeddings();
const vectorstore = await FaissStore.load(directory, embeddings);
const retriever = ScoreThresholdRetriever.fromVectorStore(vectorstore, {
minSimilarityScore: 0.8,
maxK: 3,
kIncrement: 1,
});
const res = await retriever.invoke("茴香豆是做什么用的");
console.log(res);
}
run();
核心参数:
- minSimilarityScore: 定义了最小的相似度阈值,即文档向量和 query 向量相似度达到多少,就认为是可以被返回的。这个要根据文档类型设置,一般是 0.8 左右,可以避免返回大量的文档导致消耗过多的 token
- maxK: 一次最多返回多少条数据,主要是为了避免返回太多的文档造成 token 过度的消耗
- kIncrement: 定义了算法的布厂,你可以理解成 for 循环中的 i+k 中的 k。其逻辑是每次多获取 kIncrement 个文档,然后看这 kIncrement 个文档的相似度是否满足要求,满足则返回
基于私域数据进行回答
构建一个基于本地 txt 文件,作为私域数据,来回答问题的 RAG bot。
加载环境变量
import { load } from "dotenv";
const env = await load();
const process = {
env
};
加载文章数据
import { TextLoader } from "langchain/document_loaders/fs/text";
const loader = new TextLoader("./data/qiu.txt");
const docs = await loader.load();
切割数据
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 10,
});
const splitDocs = await splitter.splitDocuments(docs);
构建 vector store 和 retriever
import { AlibabaTongyiEmbeddings } from "@langchain/community/embeddings/alibaba_tongyi";
const embeddings = new AlibabaTongyiEmbeddings({});
import { MemoryVectorStore } from "langchain/vectorstores/memory";
const vectorstore = new MemoryVectorStore(embeddings);
await vectorstore.addDocuments(splitDocs);
const retriever = vectorstore.asRetriever(2);
测试提问
const res = await retriever.invoke("原文中,谁提出了宏原子的假设?并详细介绍宏原子假设的理论");
console.log(res);
添加处理函数,将回答处理成普通文本
import { RunnableSequence } from "@langchain/core/runnables";
import { Document } from "@langchain/core/documents";
const convertDocsToString = (list) => {
return list.map((document) => document.pageContent).join("\n")
};
const contextRetriverChain = RunnableSequence.from([
(input) => input.question,
retriever,
convertDocsToString
]);
RunnableSequence 构建了一个简单的 chain,传入一个数组,并且把第一个 Runnable 对象返回的结果自动传入给后面的 Runnable 对象。 contextRetriverChain,接收一个 input 对象作为输入,获得其 question 属性,传递给 retriever,返回的 Document 对象输入作为参数传递给 convertDocsToString 转换成纯文本。
测试 chain:
const result = await contextRetriverChain.invoke({ question: "原文中,谁提出了宏原子的假设?并详细介绍宏原子假设的理论"});
console.log(result);
构建 Template
import { ChatPromptTemplate } from "@langchain/core/prompts";
const TEMPLATE = `
你是一个熟读刘慈欣的《球状闪电》的终极原著党,精通根据作品原文详细解释和回答问题,你在回答时会引用作品原文。
并且回答时仅根据原文,尽可能回答用户问题,如果原文中没有相关内容,你可以回答“原文中没有相关内容”,
以下是原文中跟用户回答相关的内容:
{context}
现在,你需要基于原文,回答以下问题:
{question}`;
const prompt = ChatPromptTemplate.fromTemplate(
TEMPLATE
);
定义 LLM 模型
import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
const model = new ChatAlibabaTongyi({
model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
temperature: 1,
});
组件完整的 Chain
import { StringOutputParser } from "@langchain/core/output_parsers";
const ragChain = RunnableSequence.from([
{
context: contextRetriverChain,
question: (input) => input.question,
},
prompt,
model,
new StringOutputParser()
]);
测试 chain bot
const res = await ragChain.invoke({
question: "什么是球状闪电"
});
console.log(res);
输出内容如下:
球状闪电是一种在《球状闪电》这部科幻小说中描述的自然现象,它并非严格意义上的科学术语,而是刘慈欣作品中的虚构概念。在书中,球状闪电被描绘为一种能产生量子态、具有神秘特性的能量体,它可以被技术手段如雷球机枪操控。尽管它的军事应用被提及,但其本质和工作原理并未详尽揭示,仅仅是宏电子技术让人类得以窥探物质微观世界的另一种方式。
const res = await ragChain.invoke({
question: "静夜思这首诗是什么"
});
console.log(res);
输出:
原文中并没有提到“静夜思”这首诗,所以无法提供相关的内容。